ExceptionHandler in Java
contents
@ExceptionHandler 어노테이션은 견고한 API를 구축하기 위해 가장 중요한 개념 중 하나입니다. 앱이 터졌을 때 지저분한 Apache Tomcat HTML 에러 페이지를 반환하는 API와, 깔끔하고 예측 가능한 JSON 에러 메시지를 반환하는 전문적인 API를 가르는 결정적인 차이가 바로 여기에 있습니다.
Spring 프레임워크에서 @ExceptionHandler 는 비즈니스 로직을 try-catch 블록으로 어지럽히지 않도록 예외 처리 로직을 한 곳으로 모아주는(Centralize) 메커니즘입니다.
작동 방식, 확장 방법, 그리고 내부 동작 원리에 대해 알아보겠습니다.
1. 문제점: "Try-Catch 지옥"
REST API를 작성하고 있다고 상상해 봅시다. 사용자가 존재하지 않는 ID로 검색을 요청하면 서비스는 UserNotFoundException을 던집니다.
아마추어 방식 (@ExceptionHandler 없음):
@RestController
public class UserController {
@GetMapping("/users/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
// 이 코드를 모든 메서드마다 일일이 작성해야 합니다!
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
}
- 문제점: 컨트롤러의 80%가 에러 처리 코드이고, 실제 로직은 20%밖에 되지 않게 됩니다. 만약 API 엔드포인트가 50개라면, 이
catch로직을 50번이나 중복해서 작성해야 합니다.
2. 첫 번째 단계: 컨트롤러 레벨 @ExceptionHandler
스프링은 @ExceptionHandler 애노테이션을 사용하여 catch 블록들을 별도의 메서드로 빼낼 수 있게 해줍니다.
@RestController
public class UserController {
// 1. 깔끔한 비즈니스 로직
@GetMapping("/users/{id}")
public ResponseEntity getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user); // try-catch가 전혀 필요 없습니다!
}
// 2. 예외 처리기 (Exception Handler)
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity handleUserNotFound(UserNotFoundException ex) {
// '이 컨트롤러' 내부에서 UserNotFoundException이 발생하면 무조건 이 메서드가 호출됩니다.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Error: " + ex.getMessage());
}
}
- 한계: 이 핸들러는
UserController내부에서 발생한 예외에만 작동합니다. 만약OrderController에서UserNotFoundException이 발생한다면 이 메서드는 잡아내지 못합니다.
3. 업계 표준: 전역 예외 처리 (Global Error Handling)
애플리케이션 전체에서 발생하는 예외를 한 번에 처리하려면, @ExceptionHandler를 @RestControllerAdvice(또는 @ControllerAdvice)라는 클래스 레벨 애노테이션과 함께 사용해야 합니다.
이것은 예외를 가로채는 전역 인터셉터(Global Interceptor) 역할을 합니다.
1단계: 표준 에러 응답 DTO 만들기
에러를 단순 문자열로 반환하지 마세요. 항상 일관된 JSON 구조를 반환해야 합니다.
public record ErrorResponse(
int status,
String error,
String message,
LocalDateTime timestamp
) {}
2단계: 전역 핸들러 만들기
@RestControllerAdvice // 스프링에게 "이 클래스를 모든 컨트롤러에 적용해라"라고 알려줍니다.
public class GlobalExceptionHandler {
// 특정 커스텀 예외 처리
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity handleNotFound(UserNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
404, "Not Found", ex.getMessage(), LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// 유효성 검사 에러 처리 (예: @Valid 실패 시)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) {
ErrorResponse error = new ErrorResponse(
400, "Bad Request", "Invalid input data", LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
// 예상치 못한 서버 에러를 위한 "최후의 보루 (Catch-All)" (NullPointerException 등)
@ExceptionHandler(Exception.class)
public ResponseEntity handleGenericException(Exception ex) {
// 예상치 못한 에러는 반드시 로그로 남겨야 합니다!
ex.printStackTrace();
ErrorResponse error = new ErrorResponse(
500, "Internal Server Error", "서버 측에 문제가 발생했습니다", LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
4. 내부 동작 원리 (Under the Hood)
스프링에서 예외가 발생하면, 이 예외는 스프링 웹의 핵심 교통경찰 역할을 하는 DispatcherServlet 으로 거슬러 올라갑니다(Bubbles up).
DispatcherServlet이 날것의 예외(Raw Exception)를 낚아챕니다.- 그리고
HandlerExceptionResolver라는 컴포넌트에게 묻습니다: "너 이거 어떻게 처리하는지 알아?" - 스프링은 애플리케이션 컨텍스트를 뒤져
@ExceptionHandler가 붙은 메서드들을 스캔합니다. - 해당 예외 타입과 일치하는 메서드를 찾아 실행하고, 그 반환값을 HTTP 응답으로 변환합니다.
5. 해결 우선순위 (어떤 핸들러가 이기는가?)
만약 UserNotFoundException을 던졌는데, UserNotFoundException 전용 핸들러도 있고 최상위 부모 클래스인 Exception 전용 핸들러도 있다면 어떻게 될까요?
- 규칙: 스프링은 항상 가장 구체적인 하위 클래스(Most specific subclass) 를 선택합니다.
- 즉, 이 경우
UserNotFoundException핸들러가 실행됩니다.Exception핸들러는 그 외 나머지 예외들을 처리하는 기본 대체 수단(Fallback)으로 작동합니다. - 지역(Local) vs 전역(Global): 특정
@RestController안에 작성된 지역@ExceptionHandler와, 전역@RestControllerAdvice에 작성된 전역 핸들러가 충돌한다면 지역 핸들러가 이깁니다. 전역 동작을 덮어씁니다(Overrides).
6. 요약 비교 테이블
| 특징 | 기존 try-catch | @ExceptionHandler (지역) | @RestControllerAdvice (전역) |
|---|---|---|---|
| 적용 범위 | 특정 메서드 하나 | 특정 컨트롤러 클래스 전체 | 애플리케이션 내 모든 컨트롤러 |
| 코드 중복 | 높음 (지저분함) | 중간 | 없음 (가장 깔끔함) |
| 관심사 분리 | 나쁨 (로직이 섞임) | 좋음 | 매우 훌륭함 |
| 사용 시나리오 | 컨트롤러에서는 절대 사용 금지 | 에러가 특정 컨트롤러에만 국한될 때 | API 개발의 업계 표준 |
상용 환경(Production)을 위한 개발자 체크리스트
- 스택 트레이스(Stack Trace)를 클라이언트에게 절대 유출하지 마세요:
500 Internal Server Error발생 시 사용자에게는 일반적인 메시지만 반환해야 하며, 실제 오류의 세부 스택 트레이스는 디버깅을 위해 서버 로그에만 남겨야 합니다. @ResponseStatus활용 (선택사항): 만약ResponseEntity로 감싸서 반환하는 것이 귀찮다면, 그냥 DTO 객체만 반환하고 핸들러 메서드 위에@ResponseStatus(HttpStatus.NOT_FOUND)를 붙여도 됩니다.- 항상 '최후의 보루(Catch-All)'를 두세요: 예측하지 못한 버그가 사용자에게 추한 에러 페이지를 보여주지 않도록, Advice 클래스 맨 아래에 반드시
@ExceptionHandler(Exception.class)를 만들어 두어야 합니다.
references